| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- 'use client';
- import { use, useEffect, useState, useRef } from 'react';
- import * as signalR from '@microsoft/signalr';
- import { DonationAlertData, DonationRemoteState } from '@/types/donation';
- import { fetchApi } from '@/lib/utils/client';
- import './style.scss';
- type Props = {
- params: Promise<{ widgetToken: string }>;
- };
- type AlertQueueItem = {
- alertID: number;
- donationID: number;
- status: string;
- sponsorMemberID: number;
- sendName: string;
- amount: number;
- message: string|null;
- channelName?: string;
- createdAt: string;
- };
- const STATUS_LABEL: Record<string, string> = {
- playing: '재생 중',
- queued: '대기',
- failed: '실패',
- delivered: '완료',
- skipped: '건너뜀',
- ignored: '무시'
- };
- export default function RemotePage({ params }: Props) {
- const { widgetToken } = use(params);
- const apiBase = '/api/donation/remote';
- const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
- const [connected, setConnected] = useState(false);
- const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
- const [queue, setQueue] = useState<AlertQueueItem[]>([]);
- const [openMenuID, setOpenMenuID] = useState<number|null>(null);
- const connectionRef = useRef<signalR.HubConnection|null>(null);
- // 초기 데이터 로드 + SignalR 연결
- useEffect(() => {
- loadState();
- connectHub();
- return () => { connectionRef.current?.stop(); };
- }, []);
- const loadState = async () => {
- try {
- const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { silent: true });
- if (res.data) {
- setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
- setQueue(res.data.queue || []);
- }
- } catch {}
- };
- const connectHub = () => {
- const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
- conn.on('ReceiveAlert', (data: DonationAlertData) => {
- setQueue(prev => [...prev, { alertID: data.alertID, donationID: data.donationID, status: 'queued', sponsorMemberID: data.sponsorMemberID, sendName: data.sendName, amount: data.amount, message: data.message, createdAt: data.createdAt }]);
- });
- conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
- conn.on('ReceiveSkip', () => {
- setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
- });
- conn.start().then(() => {
- conn.invoke('JoinChannel', widgetToken);
- setConnected(true);
- }).catch(() => {});
- conn.onclose(() => setConnected(false));
- conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
- connectionRef.current = conn;
- };
- // 리모콘 액션
- const togglePause = async () => {
- const next = { ...state, isPaused: !state.isPaused };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleAccepting = async () => {
- const next = { ...state, isAccepting: !state.isAccepting };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleAudioOnly = async () => {
- const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleVideoOnly = async () => {
- const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const skipCurrent = async () => {
- await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true });
- };
- const ignoreAlert = async (alertID: number) => {
- await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
- setOpenMenuID(null);
- };
- const resendAlert = async (alertID: number) => {
- await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
- setOpenMenuID(null);
- };
- const formatTime = (dateStr: string) => {
- const d = new Date(dateStr);
- return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
- };
- return (
- <div className="remote-container">
- <div className="remote-header">
- <h1>리모콘</h1>
- <span className={`connection-badge ${connected ? 'connected' : 'disconnected'}`}>
- {connected ? '연결됨' : '연결 끊김'}
- </span>
- </div>
- {/* 컨트롤 패널 */}
- <div className="control-panel">
- <button type="button" className={`control-btn ${state.isPaused ? 'active' : ''}`} onClick={togglePause}>
- {state.isPaused ? '▶ 재개' : '⏸ 일시정지'}
- </button>
- <button type="button" className={`control-btn ${!state.isAccepting ? 'danger' : ''}`} onClick={toggleAccepting}>
- {state.isAccepting ? '🔔 후원 받는 중' : '🔕 후원 안 받음'}
- </button>
- <button type="button" className={`control-btn ${state.isAudioOnly ? 'active' : ''}`} onClick={toggleAudioOnly}>
- 🔊 음성만
- </button>
- <button type="button" className={`control-btn ${state.isVideoOnly ? 'active' : ''}`} onClick={toggleVideoOnly}>
- 🖼 영상만
- </button>
- <button type="button" className="control-btn full-width" onClick={skipCurrent}>
- ⏭ 건너뛰기
- </button>
- </div>
- {/* 후원 목록 */}
- <div className="alert-list-header">
- <h2>후원 목록</h2>
- <span className="alert-count">{queue.length}건</span>
- </div>
- <div className="alert-list">
- {queue.length === 0 && <div className="empty-list">후원 알림이 없습니다</div>}
- {queue.map(item => (
- <div key={item.alertID} className={`alert-item ${item.status}`}>
- {/* 방향 아이콘 */}
- <div className="alert-direction">
- <span>{item.sendName}</span>
- <span className="alert-arrow">→</span>
- </div>
- {/* 본문 */}
- <div className="alert-body">
- <div className="alert-body-top">
- <span className="alert-sender-name">{item.sendName}</span>
- <span className="alert-amount">{item.amount.toLocaleString()}원</span>
- </div>
- {item.message && <div className="alert-msg">{item.message}</div>}
- <div className="alert-time">{formatTime(item.createdAt)}</div>
- </div>
- {/* 상태 뱃지 */}
- <span className={`alert-status-badge status-${item.status}`}>
- {STATUS_LABEL[item.status] || item.status}
- </span>
- {/* 햄버거 메뉴 (재생 중이 아닐 때만) */}
- {item.status !== 'playing' && (
- <div className="relative">
- <button type="button" className="alert-menu-btn" onClick={() => setOpenMenuID(openMenuID === item.alertID ? null : item.alertID)}>
- ⋮
- </button>
- {openMenuID === item.alertID && (
- <div className="alert-menu-dropdown">
- {(item.status === 'failed' || item.status === 'skipped') && (
- <div className="alert-menu-item" onClick={() => resendAlert(item.alertID)}>재전송</div>
- )}
- {item.status === 'queued' && (
- <div className="alert-menu-item danger" onClick={() => ignoreAlert(item.alertID)}>무시</div>
- )}
- </div>
- )}
- </div>
- )}
- </div>
- ))}
- </div>
- </div>
- );
- }
|